Whisper Isle 遊戲連結(PC Only):whisperisle.app(註:專案每日推進,撰文當下線上已推進至 v0.2.2)
昨天在 Day 3 解決了部署問題,雲端有了一個活著的公網環境,但一片空畫布終究不是遊戲。做一個連線 RPG,必須先把最底層的最小原子核心迴圈(Core Loop)打通:
發現怪物 → 戰鬥互動 → 擊殺獲得經驗與掉落 → 升級成長 → 怪物重生循環。
而上述這一點,在開工的第一個週末(2026-07-10 深夜到 07-11 凌晨),我和 Fable 用了約 3 個多小時完成(表格驅動內容 Data-Driven Design)。
當你放任 AI 實作一隻怪物時,它的偷懶直覺通常長這樣:
它會在後端邏輯的深處(例如 WorldRoom.ts)寫下幾行常數:
const wolfHp = 30; const wolfDamage = 6;;
或者在怪物實體的建構式裡用一堆 if-else 硬寫:if (type === 'wolf') { this.speed = 90; }。
在一人加上 AI 的協作中,這種寫法會帶來致命的誤傷半徑(Blast Radius):
當你實測後覺得「野狼血量太厚了,調低 5 點、反咬間隔拉長 0.2 秒」時,AI 就必須去翻閱幾百行包含物理運算與狀態同步的邏輯代碼。而在改動這些數值的同時,AI 極容易順手改壞旁邊的 tick 迴圈、或是漏掉某個欄位的廣播。改個數值,整個戰鬥系統可能就默默掛掉。
因此嚴格要求:程式碼只負責「讀表並套用通用規則」,所有具體數值一律抽離到 JSON 資料表。我們在 packages/shared/src/data/monsters.json 建立了第一隻怪物「野狼」的資料表:
// packages/shared/src/data/monsters.json
// packages/shared/src/data/monsters.json
{
"wolf": {
"level": 2,
"name": "野狼",
"maxHp": xxxx,
"moveSpeed": xxx,
"wanderRadius": xxx,
"wanderPauseMs": x,
"respawnMs": x,
"attackDamage": x,
"attackRange": x,
"attackCooldownMs": xxx,
"leashRadius": x,
"xpReward": 12x,
"physicalDefense": 5xx,
"magicDefense": 3xx,
"dropQuantityWeights": [
{ "quantity": 1x, "weight": xx },
{ "quantity": 3x, "weight": 2x }
],
"behavior": "xxxx",
"socialRadius": xxx,
"socialMaxResponders": 3,
"packKey": "wolf",
"drops": [
{ "itemKey": "wolf_pelt", "weight": 75 },
{ "itemKey": "wolf_fang", "weight": 25 }
]
}
}
這個表成了前後端唯一的怪物數值事實來源(SSOT):
有了一張表,伺服器在房間建立時就能依表生成指定數量(spawnCount: 6)的野狼。但怪物不能像木樁一樣死站在原地。
隨機漫遊 AI
如果所有野狼用固定頻率移動,在畫面上看起來會極其僵硬機械。在 20Hz 的 fixedTick 裡,每隻怪物在其出生地(Home)的 wanderRadius: 220 半徑內隨機挑點等速前進。走到了停下,停頓時間帶有 0.5 ~ 1.5 的隨機浮動係數(wanderPauseMs * random),自然打破了怪物群體的同步感。
在戰鬥操作與手感上,我們執行 Client 只送意圖,不送結果:
我們在 packages/shared/src/data/classes.json 建立了職業表。初期以「騎士(Knight)」為範本:
{
"knight": {
"name": "騎士",
"baseHp": 100,
"hpPerLevel": 12,
"baseDamage": 10,
"damagePerLevel": 3,
"baseMp": 30,
"mpPerLevel": 6,
"attackRange": 48,
"attackCooldownMs": 900,
"moveSpeed": 220,
"weaponTypes": ["sword", "greatsword"]
}
}
同時在 packages/shared/src/progression.ts 訂立了單一計算入口:
export function playerAttackDamageAtLevel(classKey: ClassKey, level: number, weaponBonus = 0): number {
const definition = CLASS_TABLE[classKey];
return definition.baseDamage + (level - 1) * definition.damagePerLevel + weaponBonus;
}
這個單一入口直接對應規定:跨系統數值必走單一計算入口,禁止在任何 tick 內散落 +bonus。日後無論是騎士、祕法師還是獵手,無論何時加入裝備或 Buff,伺服器計算攻擊力永遠只有這一條路徑,絕不會發生各處公式不一致的慘劇。Client 端嚴格遵循「不自算傷害、不預扣血量」。怪物被打時,由伺服器固定 tick 計算傷害並廣播 CombatHit 事件,前端收到後才彈出傷害浮動文字(Floating Combat Text)並扣除血條。

export const PLAYER_XP_TABLE: readonly number[] = [
0, 36, 96, 180, 300, 456, 660, 912, 1224, 1608, ...
];

建立骨架,數值進表格,架構設定在模組,後續 AI 就能更有效擴充職業與技能,甚至怪物的種類遞增。